import {
  CACHE_MANAGER,
  ForbiddenException,
  Inject,
  Injectable, NotFoundException,
  UnauthorizedException,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { DiscordAuthService } from './discord-auth.service'
import { CryptographyService } from './cryptography.service'
import { JwtService } from '@nestjs/jwt'
import { JwtPayloadDto } from '../dto/jwt-payload.dto'
import { Cache } from 'cache-manager'
import { AUTH_OPTIONS } from '../auth.constants'
import { AuthModuleOptions } from '../interfaces/auth-options.interface'
import { Defaults, ErrorCodes } from '@boticord/consts'
import { BitFieldUtil } from '@boticord/common'
import { UserToken } from '../models/user-token.model'
import { BotToken } from '../models/bot-token.model'
import OAuth from 'discord-oauth2'
import { randomString } from 'discordoo'
import { NotifyClientService } from '@boticord/notify-client'

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(UserToken) private readonly userTokens: Repository<UserToken>,
    @InjectRepository(BotToken) private readonly botTokens: Repository<BotToken>,
    private readonly crypto: CryptographyService,
    private readonly discord: DiscordAuthService,
    private readonly jwt: JwtService,
    private readonly notifications: NotifyClientService,
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    @Inject(AUTH_OPTIONS) private readonly options: AuthModuleOptions,
  ) {}

  // user permissions cache key
  private userPermissionsKey = (id: string) => `userPermissions:${id}`
  // bot permissions cache key
  private botPermissionsKey = (id: string) => `botPermissions:${id}`
  // resources developers cache key
  private ownsKey = (ownerID, resourceID: string) => `owns:${ownerID}:${resourceID}`

  // extract data from jwt token
  extractPayload(token: string): JwtPayloadDto {
    try {
      return this.jwt.verify(token, { secret: this.options.jwtSecret })
    } catch {
      throw new ForbiddenException({ code: ErrorCodes.TokenInvalid })
    }
  }

  // get user token from the database
  async getUserToken(id: string): Promise<UserToken | null> {
    return await this.userTokens.findOne({ where: { id } })
  }

  // get bot token from the database
  async getAutomationToken(botID: string) {
    return await this.botTokens.findOne({ where: { id: botID } })
  }

  async getSignedAutomationToken(botID: string) {
    const token = await this.botTokens.findOne({ where: { id: botID } })
    if (!token) throw new NotFoundException({ code: ErrorCodes.NotFound })

    return this.signToken({
      id: token.id,
      token: token.accessToken,
      redirect: 'ты думал тут что-то будет?',
      permissions: token.permissions,
      type: 'bot',
    })
  }

  // get user permissions pair from the database
  async getPermissions(userID: string): Promise<number> {
    const cached = await this.cache.get<string>(this.userPermissionsKey(userID))
    if (cached === undefined || cached === null) {
      const token = await this.getUserToken(userID)
      if (token === null) throw new UnauthorizedException({ code: ErrorCodes.UnknownUser })

      await this.cache.set(
        this.userPermissionsKey(userID), token.permissions, { ttl: 60 },
      )

      return token.permissions
    } else {
      return +cached
    }
  }

  // update user permissions
  async setUserPermissions(userID: string, permissions: number) {
    await this.userTokens.update(
      userID, { permissions },
    )
    return this.cache.set(
      this.userPermissionsKey(userID), permissions, { ttl: 60 },
    )
  }

  // check if user has the required permissions
  verifyPermissions(permissions: number, required: number) {
    if (BitFieldUtil.has(permissions, required)) {
      return true
    } else {
      throw new ForbiddenException({ code: ErrorCodes.PermissionDenied })
    }
  }

  // create a new user token
  async newUserToken(userID: string, redirect: string) {
    const pair = await this.getTokenPair(userID, redirect)
    if (pair === null) throw new UnauthorizedException({ code: ErrorCodes.ThirdPartyFail })

    return this.signToken({
      id: userID,
      token: pair.accessToken,
      type: 'bearer',
      permissions: pair.permissions,
      redirect,
    })
  }

  async authorize(code: string, redirect: string) {
    const r = await this.authorizeWithData(code, redirect)
    return r.token
  }  

  // authenticate user
  async authorizeWithData(code: string, redirect: string): Promise<{ token: string; userData: OAuth.User }> {
    // get discord token
    const pair = await this.discord.exchange(code, redirect)
    // get user from discord
    const userData = await this.discord.getUser(pair.access_token)

    // check if valid token exists for user
    const token = await this.userTokens.findOne({ where: { id: userData.id } })
    if (token === null) {
      // create new token if not
      return { token: await this.makeUserToken(redirect, userData, pair), userData }
    }

    // if token exists, check if it is expired and refresh if needed
    const dbPair = await this.getTokenPair(userData.id, pair.access_token, pair.refresh_token)
    if (dbPair === null) {
      // create new token if not
      return { token: await this.makeUserToken(redirect, userData, pair), userData }
    }

    // create jwt token & sign it & return it
    return {
      userData,
      token: this.signToken({
        token: dbPair.accessToken,
        id: userData.id,
        type: 'bearer',
        permissions: dbPair.permissions,
        redirect,
      }),
    }
  }

  /**
   * gets token pair for the user from the database. if they are expired, it will refresh them, and save them to the database.
   * we can pass token pair here only if we know that the token is not expired.
   * we will use it, if we know that the token is not expired, instead of making a request to discord.
   * */
  private async getTokenPair(
    userID: string, redirect: string, accessToken?: string, refreshToken?: string, expiresIn = 604800,
  ): Promise<{ accessToken: string; refreshToken: string; permissions: number } | null> {
    const token = await this.userTokens.findOne({ where: { id: userID } })
    if (token === null) throw new UnauthorizedException({ code: ErrorCodes.UnknownUser })

    // if token is expired, refresh it
    if (token.expiresAt <= Date.now()) {
      const oldRefresh = this.crypto.aesDecrypt(token.refreshToken)
      let newPair: {
        access_token: string
        refresh_token: string
        expires_in: number
      } = {} as any

      if (accessToken !== undefined && refreshToken !== undefined) {
        newPair.access_token = accessToken
        newPair.refresh_token = refreshToken
      } else {
        const refreshedPair = await this.discord.refresh(oldRefresh, redirect).catch(() => null)
        if (refreshedPair === null) return null
        newPair = refreshedPair
      }

      token.accessToken = this.crypto.aesEncrypt(newPair.access_token)
      token.refreshToken = this.crypto.aesEncrypt(newPair.refresh_token)
      token.expiresAt = Date.now() + (newPair.expires_in ?? expiresIn) * 1000

      await this.userTokens.save(token)

      return {
        accessToken: newPair.access_token,
        refreshToken: newPair.refresh_token,
        permissions: token.permissions,
      }
    } else {
      // if token is not expired, check if it valid and return it
      const accessToken = this.crypto.aesDecrypt(token.accessToken)
      const user = await this.discord.getUser(accessToken).catch(() => null)
      if (user === null) return null
      return {
        accessToken,
        refreshToken: this.crypto.aesDecrypt(token.refreshToken),
        permissions: token.permissions,
      }
    }
  }

  private async makeUserToken(redirect: string, userData: OAuth.User, pair: OAuth.TokenRequestResult): Promise<string> {
    const oldToken = await this.userTokens.findOne({ where: { id: userData.id } })
    const token = new UserToken()

    token.accessToken = this.crypto.aesEncrypt(pair.access_token) // encrypt access token
    token.refreshToken = this.crypto.aesEncrypt(pair.refresh_token) // encrypt refresh token
    token.permissions = typeof oldToken?.permissions === 'number'
      ? oldToken.permissions
      : Defaults.DefaultUserPermissions
    token.id = userData.id
    token.expiresAt = Date.now() + pair.expires_in * 1000 // set expiration time

    // save encrypted tokens and other data to database
    await this.userTokens.save(token)

    // create jwt token & sign it & return it
    return this.signToken({
      id: userData.id,
      token: pair.access_token,
      type: 'bearer',
      permissions: token.permissions,
      redirect,
    })
  }

  public async makeAutomationToken(id: string): Promise<string> {
    const oldToken = await this.botTokens.findOne({ where: { id } })
    const token = new BotToken()

    token.id = id
    token.accessToken = this.crypto.aesEncrypt(randomString())
    token.permissions = oldToken ? oldToken.permissions : Defaults.DefaultBotPermissions

    await this.botTokens.save(token)

    return this.signToken({
      id: id,
      token: token.accessToken,
      type: 'bot',
      redirect: 'ты думал тут что-то будет?',
      permissions: token.permissions,
    })
  }

  async revokeAutomationToken(id: string) {
    const token = await this.botTokens.findOne({ where: { id } })

    if (token === null) throw new NotFoundException({ code: ErrorCodes.UnknownAutomation })

    const removed = await this.botTokens.remove(token)
    await this.notifications.forceDisconnect(id).catch(() => null)
    return !!removed
  }

  /** creates new jwt token, signs it, and returns it */
  private signToken(payload: JwtPayloadDto) {
    return this.jwt.sign(payload, { secret: this.options.jwtSecret })
  }
}
